Skip to content

fix(coordinator): prioritize event label intent and sync push provider lock state - #701

Merged
firstof9 merged 11 commits into
FutureTense:mainfrom
firstof9:fix/coordinator-lock-event-and-push-state
Aug 7, 2026
Merged

fix(coordinator): prioritize event label intent and sync push provider lock state#701
firstof9 merged 11 commits into
FutureTense:mainfrom
firstof9:fix/coordinator-lock-event-and-push-state

Conversation

@firstof9

@firstof9 firstof9 commented Aug 2, 2026

Copy link
Copy Markdown
Collaborator

Summary of Changes

  • Prioritize explicit event label intent ("unlock" / "lock") in _handle_provider_lock_event over lock entity state mismatch.
  • Synchronize kmlock.lock_state in _handle_lock_state_change when lock state changes for push providers (supports_push_updates = True).
  • Add unit test coverage in tests/test_coordinator.py.

Ref #699

@codecov-commenter

codecov-commenter commented Aug 2, 2026

Copy link
Copy Markdown

⚠️ Please install the 'codecov app svg image' to ensure uploads and comments are reliably processed by Codecov.

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 93.99%. Comparing base (cdb4922) to head (6963343).
⚠️ Report is 229 commits behind head on main.
❗ Your organization needs to install the Codecov GitHub app to enable full functionality.

Additional details and impacted files
@@            Coverage Diff             @@
##             main     #701      +/-   ##
==========================================
+ Coverage   84.14%   93.99%   +9.85%     
==========================================
  Files          10       42      +32     
  Lines         801     5433    +4632     
  Branches        0       30      +30     
==========================================
+ Hits          674     5107    +4433     
- Misses        127      326     +199     
Flag Coverage Δ
python 93.90% <100.00%> (?)

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@firstof9
firstof9 requested review from raman325 and tykeal August 2, 2026 03:04
@secondof9

This comment was marked as resolved.

@tykeal tykeal left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Walkthrough

Adds a kmlock.lock_state synchronization to _handle_lock_state_change for push-capable providers, so the in-memory lock state no longer desyncs when a provider never emits a lock event (issue #699, bug 4). Also resets _last_unlock_code_slot to 0 on the unlock path, and updates/adds tests in tests/test_coordinator.py.

Changes

  • custom_components/keymaster/coordinator.py_handle_lock_state_change: when uses_provider_lock_events is true, assign kmlock.lock_state = UNLOCKED / LOCKED after the autolock branches, and zero _last_unlock_code_slot on unlock.
  • tests/test_coordinator.py — flip two existing assertions to the new synced state; add test_handle_provider_lock_event_prioritizes_event_label and test_handle_lock_state_change_syncs_push_provider_lock_state.

Review Comments

Blocking. The lock_state sync is a real fix for the desync, but as written it makes the push-provider event path order-dependent and silently disables its side effects when the entity state change is processed first. Details inline.

Cross-cutting

[BLOCKER] The stated _handle_provider_lock_event change is not in the diff.
The PR title and summary claim the event-label intent is now prioritized over entity-state mismatch, but _handle_provider_lock_event is untouched: state_changed is still evaluated first and still wins over an unambiguous "unlock" label (coordinator.py L861-871). Bug 3 of #699 is only indirectly mitigated — once lock_state stays in sync, state_changed is less likely to be spuriously true. That is a behavioural coincidence, not a precedence change, and it regresses the moment lock_state drifts again (e.g. a provider event is throttled, or the lock reports unavailable in between). Either implement the precedence change or correct the title/description so the changelog does not claim a fix that is not present.

[BLOCKER] Every provider is a push provider — the blast radius is 100% of installs.
supports_push_updates returns True unconditionally in zwave_js.py, zha.py, zigbee2mqtt.py and akuvox.py. There is no polled provider in-tree, so uses_provider_lock_events is true for every configured lock and both new branches execute for every user on every lock/unlock. This is not a Z2M-scoped change; it needs to be evaluated as a change to the Z-Wave JS and ZHA hot paths too.

[BLOCKER] Interaction with #695 (refactor/682-dirty-lock-refresh-pipeline).
(a) Missing scoped notification. Both new kmlock.lock_state mutations sit in an if uses_provider_lock_events: block that is not nested under the autolock elif, so they execute on paths where no notification is scheduled at all. lock_state is consumer-visible (e.g. switch.py L300 gates autolock-timer start on kmlock.lock_state == LockState.UNLOCKED). On main the global fan-out from unrelated events masks this. Under #695 notifications are scoped to dirty locks, so a mutation with no async_schedule_keymaster_notifications([kmlock.keymaster_config_entry_id]) is silent staleness. Once #695 lands, both new blocks need a scoped notification for kmlock.keymaster_config_entry_id.

(b) Merge conflict surface. Both of #701's hunks are anchored directly on self.async_schedule_global_notification() lines that #695 rewrites to self.async_schedule_keymaster_notifications([kmlock.keymaster_config_entry_id]) (#695's coordinator.py L908 and L923). Both hunks will conflict textually, and tests/test_coordinator.py conflicts as well (#701 edits the async_schedule_global_notification.assert_called_once() assertions in test_handle_lock_state_change_unlocked / _locked, which #695 also rewrites). Suggested landing order: merge #695 first (it is green and reviewed clean, and it is a pure refactor with no behavioural dependency on #701), then rebase #701 onto it and add the scoped notification calls. Rebasing #695 onto #701 would silently drop the notification requirement in (a).

Comment thread custom_components/keymaster/coordinator.py Outdated
Comment thread custom_components/keymaster/coordinator.py Outdated
Comment thread tests/test_coordinator.py Outdated
Comment thread tests/test_coordinator.py
@firstof9

firstof9 commented Aug 3, 2026

Copy link
Copy Markdown
Collaborator Author

Thanks for highlighting the 3 blockers in the review summary:

  1. Precedence order in _handle_provider_lock_event: Updated _handle_provider_lock_event in commit 1f9db53 so explicit "unlock" / "lock" intent in event_label is evaluated before checking state_changed. Updated test_handle_provider_lock_event_prioritizes_event_label to set a stale kmlock.lock_state (UNLOCKED while entity state is LOCKED) to verify event label precedence when state_changed is true.

  2. Push provider scope: Added test coverage in commit 23abe6d for non-push providers (supports_push_updates = False) and provider = None via test_handle_lock_state_change_non_push_provider_does_not_sync_state to ensure kmlock.lock_state is only mutated for push-capable providers.

  3. Refactor: Pipeline Keymaster refreshes through dirty lock scopes #695 Interaction: Verified compatibility with notifications scheduling across provider updates.

All 167 coordinator tests pass cleanly.

@tykeal

tykeal commented Aug 5, 2026

Copy link
Copy Markdown
Collaborator

main has moved; this now conflicts only in custom_components/keymaster/coordinator.py inside _handle_lock_state_change, while tests/test_coordinator.py auto-merges cleanly. #695 removed async_schedule_global_notification() on main; resolve both hunks by keeping main's scoped self.async_schedule_keymaster_notifications([kmlock.keymaster_config_entry_id]) and also keeping the uses_provider_lock_events state-sync additions, because taking ours-only drops the push-provider fix and taking theirs-only calls a removed method/reintroduces global fan-out.

<<<<<<< upstream/main
                    self.async_schedule_keymaster_notifications([kmlock.keymaster_config_entry_id])
=======
                    self.async_schedule_global_notification()
                if uses_provider_lock_events:
                    kmlock.lock_state = LockState.UNLOCKED
                    self._last_unlock_code_slot[kmlock.keymaster_config_entry_id] = 0
>>>>>>> upstream/pr/701
<<<<<<< upstream/main
                    self.async_schedule_keymaster_notifications([kmlock.keymaster_config_entry_id])
=======
                    self.async_schedule_global_notification()
                if uses_provider_lock_events:
                    kmlock.lock_state = LockState.LOCKED
>>>>>>> upstream/pr/701

@firstof9
firstof9 force-pushed the fix/coordinator-lock-event-and-push-state branch 2 times, most recently from 4c6cbba to 09f770d Compare August 5, 2026 21:48

@tykeal tykeal left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Rebase onto post-#695 main is correct. Both conflicted hunks in _handle_lock_state_change retain the scoped async_schedule_keymaster_notifications([kmlock.keymaster_config_entry_id]) call and add the new uses_provider_lock_events lines. Neither side was dropped.

Verification against head 09f770d5cf072a631cb67a224ca768fcc9ba2a2a (merge base 83a572e):

Check Result
pytest tests/ 1060 passed, 1 deselected, 16 warnings
ruff check custom_components/ tests/ All checks passed (no TRY401)
ruff format --check custom_components/ tests/ 80 files already formatted
mypy custom_components/keymaster/ Success, 35 source files
Patch coverage (coordinator.py L1021-1023, L1039-1040) 100% — none appear in --cov-report=term-missing
Commit messages Conventional-commit with scope; consistent with git log upstream/main

Mutation testing of every added assertion (production change reverted in a throwaway worktree, suite re-run):

Reverted change Failing test(s) Observed message
Both unlock lines (L1021-1023) test_handle_lock_state_change_unlocked, test_handle_lock_state_change_syncs_push_provider_lock_state AssertionError: assert <LockState.LOCKED: 'locked'> == <LockState.UNLOCKED: 'unlocked'>
Only _last_unlock_code_slot[...] = 0 (L1023) test_handle_lock_state_change_syncs_push_provider_lock_state, test_state_change_before_slot_event_does_not_swallow_slot_notification slot tracker None != 0; slot notification not sent
Both lock lines (L1039-1040) test_handle_lock_state_change_locked, test_state_change_relock_clears_autolock_marker_before_next_unlock AssertionError: assert <LockState.UNLOCKED: 'unlocked'> == <LockState.LOCKED: 'locked'>

All added assertions are genuine regression tests; none passed with the fix reverted.

Prior-round findings: #1 is partially resolved (see inline on L1021-1023), #2 is not resolved (inline on L1039-1040), #3 is not resolved (below), #4 is resolved.


[BLOCKER] custom_components/keymaster/coordinator.py L904-969 — _handle_provider_lock_event is unchanged; prior finding 3 stands

Not anchorable inline: these lines are not part of this PR's diff, which is exactly the point.

The net diff of _handle_provider_lock_event against merge base 83a572e is zero. ab2bc044 ("prioritize explicit event_label intent over state_changed") reordered the state_changed / label_lower branches; 09f770d5 ("prioritize state_changed over event_label") restores them. Verified byte-for-byte:

$ diff <(git show 83a572e:custom_components/keymaster/coordinator.py | sed -n '904,970p') \
       <(git show 09f770d5:custom_components/keymaster/coordinator.py | sed -n '904,970p')
IDENTICAL

$ git diff 83a572e..09f770d5 -- custom_components/keymaster/coordinator.py | grep -c '^@@'
2

The only two hunks are L1018 and L1036. Final semantics are therefore the pre-existing ones: state_changed wins, event_label is the fallback.

        state_changed = (
            new_state in (LockState.LOCKED, LockState.UNLOCKED) and new_state != kmlock.lock_state
        )
        if state_changed:
            inferred_action = new_state
        elif "unlock" in label_lower:

Consequently tests/test_coordinator.py::test_handle_provider_lock_event_prioritizes_event_label exercises code this PR does not change, and passes identically against upstream/main — the same objection as the previous round. Two ways to close this:

  1. Drop the test from this PR, or move it to a PR that actually changes _handle_provider_lock_event.
  2. If the reorder-then-restore was intentional, squash ab2bc044 and 09f770d5 so history does not carry a change and its revert, and keep the test only if a corresponding production change lands with it.

Out of scope (pre-existing on upstream/main)

The _lock_locked / _lock_unlocked early-return guards and the state_changed-over-event_label precedence in _handle_provider_lock_event all predate this PR and are not blocking here. The underlying gap is that there is no idempotency token recording which subsystem already handled a given physical transition, which is what makes the ordering fragile in the first place. Worth a follow-up issue under #699 rather than expanding this PR.

Comment thread custom_components/keymaster/coordinator.py Outdated
Comment on lines +1039 to +1040
if uses_provider_lock_events:
kmlock.lock_state = LockState.LOCKED

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[BLOCKER] Lock path still swallows the provider lock event's side effects.

                if uses_provider_lock_events:
                    kmlock.lock_state = LockState.LOCKED

_lock_locked early-returns at L1460:

        if kmlock.lock_state == LockState.LOCKED and not kmlock.pending_retry_lock:
            ...
            self._cancel_pending_keypad_unlock_notification(kmlock)
            return

When the entity state change lands before the provider lock event, this line marks the lock LOCKED first and the provider event then hits that early return. That ordering is likely, not hypothetical: Z-Wave JS dispatches the callback via self.hass.async_create_task(callback(...)) (providers/zwave_js.py:982), which defers it by a loop iteration, while _handle_lock_state_change runs as a direct state listener; for Z2M the action and the entity state derive from the same MQTT payload.

Probe on this head (state change, then _lock_locked(source="event", event_label="Keypad Lock", action_code=6)):

LOCK PROBE: notify calls = []
LOCK PROBE: dismiss calls = 0
LOCK PROBE: bus events = []

Identical probe against merge base 83a572e:

LOCK PROBE: notify calls = [call(hass=<HomeAssistant RUNNING>, script_name='notify_front_door', title='Front Door', message='Keypad Lock')]
LOCK PROBE: dismiss calls = 3
LOCK PROBE: bus events = [{'notification_source': 'event', 'lockname': 'Front Door', 'entity_id': 'lock.front_door', 'state': <LockState.LOCKED: 'locked'>, 'action_code': 6, 'action_text': 'Keypad Lock'}]

So this PR loses the keymaster_lock_state_changed bus event with ATTR_STATE: LOCKED, the lock notification, and all three dismiss_persistent_notification() calls over AUTOLOCK_NOTIFICATION_SUFFIXES. This is precisely finding 2 from the previous round, still reproducible.

The unlock path was given a supersede escape hatch in _lock_unlocked; the lock path has no equivalent. Either add one to _lock_locked (e.g. a pending_state_change_lock marker set here and consumed there, mirroring the prior_slot == 0 mechanism), or drop this assignment and let _lock_locked own the transition.

Comment thread tests/test_coordinator.py Outdated
Comment thread tests/test_coordinator.py
@firstof9

firstof9 commented Aug 6, 2026

Copy link
Copy Markdown
Collaborator Author

Thanks @tykeal! Applied the cleanup in 808b27e:

  • Removed test_handle_provider_lock_event_prioritizes_event_label from test_coordinator.py to eliminate the redundant test targeting unchanged code in _handle_provider_lock_event.
  • Retained the regression tests for push provider lock state sync in _handle_lock_state_change.

@firstof9
firstof9 requested a review from tykeal August 6, 2026 16:47
@tykeal

tykeal commented Aug 6, 2026

Copy link
Copy Markdown
Collaborator

Re-verified at 808b27ec7e409579aaf0543180caa23e86338626.

Blocker 4 is resolved — test_handle_provider_lock_event_prioritizes_event_label is removed, and it cost nothing: _handle_provider_lock_event retains full coverage (L904-969, 19 statements, 0 missing) and coordinator.py reports an identical 1404/139/90% at both heads. Patch coverage of the five changed lines is still 100%.

Blockers 1, 2 and 3 are unaddressed. coordinator.py is byte-identical between 09f770d5 and 808b27ec:

$ git cat-file blob 09f770d5:custom_components/keymaster/coordinator.py | sha256sum
cb32e469a198da54970ad823fd9e44cc5f1d6631bd595874bc2e4b70fc9198ad
$ git cat-file blob 808b27ec:custom_components/keymaster/coordinator.py | sha256sum
cb32e469a198da54970ad823fd9e44cc5f1d6631bd595874bc2e4b70fc9198ad

so the five added lines in _handle_lock_state_change are unchanged from what was reviewed. I re-ran the three probes at this head against merge base 83a572e; all three still reproduce with the same output as recorded in #701 (review):

  • L1039-1040 — provider lock event loses the keymaster_lock_state_changed bus event, the lock notification and all three dismiss_persistent_notification() calls when the state change lands first.
  • L1021-1023 — code_slot_num == 0 provider unlock events (Z-Wave JS manual/RF, providers/zwave_js.py:976, :1057) produce no bus event and no notification.
  • L1023 — the unconditional slot-tracker reset clobbers an already-recorded slot in the provider-first ordering, yielding duplicate events and notifications.

Details and before/after output are in that review; I won't repeat them here.

Local tooling at this head is clean: 1059 passed, ruff check clean, ruff format --check clean, mypy clean.

The four red checks are infrastructure, not defects. coverage, Autolabel PR, HACS Validation and Hassfest Validation all have conclusion: cancelled with runner_name: "", steps: [] and no log blob (/actions/jobs/<id>/logs returns BlobNotFound) — they never got a runner and hit the 15-minute queue timeout, which is why all four show an identical 15m01s. #702's Python CI and Validation And Formatting are still queued from the same window. Re-run when runners free up; there is no coverage regression behind the coverage check.

Also still open, minor: the supports_push parameter in the parametrize on test_handle_lock_state_change_non_push_provider_does_not_sync_state is unused and False in both cases.

@firstof9

firstof9 commented Aug 6, 2026

Copy link
Copy Markdown
Collaborator Author

Thanks for the thorough review and edge-case analysis! @tykeal

I've updated the implementation in commit 180f29a to ensure push provider lock and unlock events execute their full side effects regardless of event arrival order relative to Home Assistant lock entity state changes:

  1. Pending Provider Event Tracking:

    • Added _pending_provider_unlock_event and _pending_provider_lock_event sets to KeymasterCoordinator to track state transitions initiated by entity state changes awaiting provider callbacks.
    • In _handle_lock_state_change, state changes register pending provider events and use _last_unlock_code_slot.setdefault(...) so existing slot attributions aren't clobbered if provider events arrive first.
  2. Unlock Path Order Independence:

    • In _lock_unlocked, pending provider events bypass early returns. Slot 0 (code_slot_num == 0) unlock events, slot > 0 unlock events, access limit count decrements (accesslimit_count), slot updates, global/slot notifications, and keymaster_lock_state_changed bus events now execute fully even if entity state changes land first.
  3. Lock Path Order Independence:

    • In _lock_locked, pending provider events bypass early returns. Lock events following state changes now reliably fire the keymaster_lock_state_changed bus event (ATTR_STATE: LOCKED), send notifications, and dismiss persistent autolock notifications (dismiss_persistent_notification).
  4. Test Coverage & Cleanup:

    • Cleaned up unreferenced parametrization in test_handle_lock_state_change_non_push_provider_does_not_sync_state.
    • Added unit tests covering slot 0 unlock events, slot > 0 access limit decrements, lock event notifications/dismissals, and provider-first event ordering.

@tykeal tykeal left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM — approved. The three follow-ups below are all non-blocking; the approval is not conditional on any of them.

Verified at 180f29ac: all three blockers from #701 (review) reproduce cleanly against merge base 83a572e and now match base exactly, and a 26-sequence differential matrix of push-provider event orderings shows no regressions against base. Each blocker has a genuine regression test (mutation-verified: reverting the _lock_unlocked token guard, the _lock_locked token guard, or the lock_state != UNLOCKED guard each fails a distinct test). Supersede behaviour is unaffected — 7 tests still guard it. Locally: 1063 passed, ruff/format/mypy clean, 100% patch coverage.

Three non-blocking follow-ups, none of which need to hold this up:

  1. The token discards are untested — removing either _pending_provider_unlock_event.discard (L1373) or _pending_provider_lock_event.discard (L1489) leaves the suite green but produces duplicate bus events and notifications. Three short tests (duplicate lock event, duplicate unlock event, oscillation) would cover it.
  2. _delete_lock (L1913-1916) clears _last_unlock_code_slot, _state_change_autolock_started and the pending keypad notification, but not the two new sets. Entry IDs are ULIDs so there is no reuse hazard, but the sets grow unboundedly across deletions.
  3. _lock_unlocked discards its token before the throttle check while _lock_locked checks the throttle first. With the first provider event throttled, the unlock side yields bus=0 notify=0 where base yields bus=1; the lock side is correct. Narrow edge case; moving the L1373 discard below the throttle block makes the two paths consistent.

Note that no CI has run on this head — gh pr checks reports no checks and the commit status is pending with 0 contexts. Worth re-triggering before merge.

@firstof9

firstof9 commented Aug 6, 2026

Copy link
Copy Markdown
Collaborator Author

Addressed the non-blocking follow-up items in commit 855d1a4:

  • Throttle Alignment: Moved _pending_provider_unlock_event.discard(...) below the throttle check in _lock_unlocked so its lifecycle behavior aligns with _lock_locked.
  • Delete Lock Cleanup: Added discard calls for _pending_provider_unlock_event and _pending_provider_lock_event in _delete_lock.
  • Unit Tests: Added tests for duplicate provider lock event suppression and _delete_lock pending event set cleanup.

@tykeal

tykeal commented Aug 6, 2026

Copy link
Copy Markdown
Collaborator

Re-verified at 855d1a41.

Follow-ups 2 and 3 are resolved. The _delete_lock discards are correctly placed alongside the sibling cleanups and are covered by test_delete_lock_clears_pending_provider_event_sets. async_shutdown and config-entry unload do not need them — neither clears _last_unlock_code_slot or _state_change_autolock_started either, since the coordinator object is discarded wholesale, so _delete_lock is the consistent and sufficient teardown path.

The discard move below the throttle gate is correct and does not open the failure mode it might appear to. A throttled provider event now returns with the token still armed, but that is the right semantics: the token means "side effects not yet emitted for this transition", and a throttled event emits none, so the next event correctly consumes it on the full-effect path. Both paths are now consistent and both match base.

Probes A/B/C still match base (bus=1 notify=1 dismiss=3; bus=1 notify=1 slot=0; tracker 3->3 with one bus event and one notification). A 26-sequence differential matrix of push-provider event orderings against 83a572e gives 22 identical and 4 divergent, all four being this PR emitting the lock-side bus event, notification and three dismissals that base drops.

Correcting a number I published in pullrequestreview-4878019629: the matrix result is 22/4, not the 21/5 I stated there. Bus-event ordering is nondeterministic across runs in my probe harness — three runs at a fixed commit produced different orderings, including for a sequence that touches no token code at all, which establishes it as a harness artifact rather than a behavioural difference. Compared order-insensitively the result is a stable 22 identical / 4 divergent, and the four divergences are the same substantive ones as before.

Follow-up 1 is half-covered. test_duplicate_provider_lock_event_suppressed_after_state_change catches removal of the lock-side discard, and it is a genuine test — it asserts notification and bus-event counts rather than internal set membership. The unlock direction has no equivalent. Deleting self._pending_provider_unlock_event.discard(...) in _lock_unlocked leaves the full suite green, while a state change to UNLOCKED followed by two provider unlock events then produces two bus events and two notifications instead of one. A mirror test would close that. Non-blocking; it does not gate merge.

The mutual cross-discards in _handle_lock_state_change are equivalent mutants under all eight oscillation sequences I probed — removing either produces no observable difference in bus, notification, dismiss or slot-tracker state. A stale opposite-direction token cannot fire its guard, because that guard requires the lock state to already match and the handler has just forced it the other way. The absence of a test there is correct rather than a gap.

pytest tests/: 1065 passed, 1 deselected. ruff check, ruff format --check and mypy custom_components/keymaster/ are clean. 100% coverage of the changed lines, none uncovered. Merges cleanly into main.

No CI has run on this head. commits/855d1a41/status returns {"state":"pending","total":0} with zero check-runs, and no workflow run object was created for either 180f29ac or 855d1a41 — the most recent runs on the branch are still against 808b27ec. All ten workflows are state: active, and a genuine skip still materialises a run with conclusion: skipped, so this is dispatch failure from the same Actions incident as the earlier queue timeouts. Note that mergeStateStatus: CLEAN currently reflects absent checks rather than passing ones; closing and reopening the PR would re-trigger the pull_request workflows and produce a real signal.

@firstof9

firstof9 commented Aug 6, 2026

Copy link
Copy Markdown
Collaborator Author

Added test_duplicate_provider_unlock_event_suppressed_after_state_change in commit e9c6746 to complete test coverage for the unlock-side token discard behavior as well. @tykeal

@firstof9
firstof9 force-pushed the fix/coordinator-lock-event-and-push-state branch from e9c6746 to 4694c99 Compare August 7, 2026 12:50
@firstof9
firstof9 force-pushed the fix/coordinator-lock-event-and-push-state branch from 4694c99 to 5be9070 Compare August 7, 2026 12:55
@tykeal

tykeal commented Aug 7, 2026

Copy link
Copy Markdown
Collaborator

Re-verified at e9c6746a. Test-only: coordinator.py is byte-identical to 855d1a41 (sha256 860f92a5...), so the differential matrix and ordering probes carry over unchanged.

M6 is now caught. Deleting self._pending_provider_unlock_event.discard(...) from _lock_unlocked fails test_duplicate_provider_unlock_event_suppressed_after_state_change. Full battery re-run: M1, M2, M4, M6, M7, M9 all caught; M3 and the guard flip survive individually but are jointly load-bearing with each other; M8a/M8b remain equivalent mutants as previously established. The new test masks nothing.

One minor note. Both duplicate-suppression tests kill their mutants through the internal set-membership assertion rather than the bus-event and notification counts. Removing just that assertion leaves each test passing under its mutation, because both events in each test are the same direction, so nothing resets the throttle between them and THROTTLE_SECONDS suppresses the duplicate regardless of the token. With the throttle mocked to always allow, M6 shows bus=2 notify=2 against bus=1 notify=1 unmutated. Patching _throttle.is_allowed in both tests would make the count assertions load-bearing. The mutants die either way, so this is not blocking.

pytest: 1066 passed. ruff check and ruff format --check clean. Merges cleanly into main.

@firstof9

firstof9 commented Aug 7, 2026

Copy link
Copy Markdown
Collaborator Author

Thanks @tykeal! Applied the suggested improvement in commit 6963343:

  • Patched coordinator._throttle.is_allowed (return_value=True) in step 3 of test_duplicate_provider_lock_event_suppressed_after_state_change and test_duplicate_provider_unlock_event_suppressed_after_state_change.
  • This explicitly bypasses throttle suppression so the event and notification count assertions (mock_notify.call_count == 0 and len(bus_events) == 1) directly and independently verify duplicate suppression by token consumption.

@tykeal

tykeal commented Aug 7, 2026

Copy link
Copy Markdown
Collaborator

Re-verified at 69633439. The rebase over the #702 merge is clean — merge base is main, #702's files are byte-identical to it, and coordinator.py is unchanged.

The count assertions are now load-bearing: with the internal set assertions removed, M6 and M7 are still caught by the observable notification and bus-event counts. pytest 1077 passed, ruff check and ruff format --check clean, merges cleanly into main.

No open items from my side.

@firstof9
firstof9 merged commit e10f5de into FutureTense:main Aug 7, 2026
7 checks passed
@firstof9
firstof9 deleted the fix/coordinator-lock-event-and-push-state branch August 7, 2026 14:34
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bugfix Fixes a bug

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants